/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License;
 * you may not use this file except in compliance with the Elastic License.
 */
package org.elasticsearch.xpack.security.cli;

import joptsimple.OptionParser;
import joptsimple.OptionSet;
import joptsimple.OptionSpec;
import joptsimple.OptionSpecBuilder;
import org.bouncycastle.asn1.DERIA5String;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMEncryptor;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.cli.EnvironmentAwareCommand;
import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.LoggingAwareMultiCommand;
import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.Terminal.Verbosity;
import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.CheckedConsumer;
import org.elasticsearch.common.CheckedFunction;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.SuppressForbidden;
import org.elasticsearch.common.io.PathUtils;
import org.elasticsearch.common.network.InetAddresses;
import org.elasticsearch.common.util.set.Sets;
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
import org.elasticsearch.common.xcontent.LoggingDeprecationHandler;
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
import org.elasticsearch.common.xcontent.ObjectParser;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.env.Environment;
import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
import org.elasticsearch.xpack.core.ssl.PemUtils;

import javax.security.auth.x500.X500Principal;

import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.nio.CharBuffer;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.security.Key;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * CLI tool to make generation of certificates or certificate requests easier for users
 */
public class CertificateTool extends LoggingAwareMultiCommand {

    private static final String AUTO_GEN_CA_DN = "CN=Elastic Certificate Tool Autogenerated CA";
    private static final String DESCRIPTION = "Simplifies certificate creation for use with the Elastic Stack";
    private static final String DEFAULT_CSR_ZIP = "csr-bundle.zip";
    private static final String DEFAULT_CERT_ZIP = "certificate-bundle.zip";
    private static final String DEFAULT_CA_ZIP = "elastic-stack-ca.zip";
    private static final String DEFAULT_CA_P12 = "elastic-stack-ca.p12";
    private static final BouncyCastleProvider BC_PROV = new BouncyCastleProvider();

    static final String DEFAULT_CERT_NAME = "instance";

    /**
     * Used to test whether passwords are ASCII (which PKCS/PBE requires)
     */
    private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder();

    private static final int DEFAULT_DAYS = 3 * 365;
    private static final int FILE_EXTENSION_LENGTH = 4;
    static final int MAX_FILENAME_LENGTH = 255 - FILE_EXTENSION_LENGTH;
    private static final Pattern ALLOWED_FILENAME_CHAR_PATTERN =
        Pattern.compile("[a-zA-Z0-9!@#$%^&{}\\[\\]()_+\\-=,.~'` ]{1," + MAX_FILENAME_LENGTH + "}");
    private static final int DEFAULT_KEY_SIZE = 2048;

    /**
     * Wraps the certgen object parser.
     */
    private static class CertificateToolParser {
        private static final ObjectParser<List<CertificateInformation>, Void> PARSER = new ObjectParser<>("certgen");

        // if the class initializer here runs before the main method, logging will not have been configured; this will lead to status logger
        // error messages from the class initializer for ParseField since it creates Logger instances; therefore, we bury the initialization
        // of the parser in this class so that we can defer initialization until after logging has been initialized
        static {
            @SuppressWarnings("unchecked") final ConstructingObjectParser<CertificateInformation, Void> instanceParser =
                new ConstructingObjectParser<>(
                    "instances",
                    a -> new CertificateInformation(
                        (String) a[0], (String) (a[1] == null ? a[0] : a[1]),
                        (List<String>) a[2], (List<String>) a[3], (List<String>) a[4]));
            instanceParser.declareString(ConstructingObjectParser.constructorArg(), new ParseField("name"));
            instanceParser.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("filename"));
            instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("ip"));
            instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("dns"));
            instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("cn"));

            PARSER.declareObjectArray(List::addAll, instanceParser, new ParseField("instances"));
        }
    }


    public static void main(String[] args) throws Exception {
        exit(new CertificateTool().main(args, Terminal.DEFAULT));
    }

    CertificateTool() {
        super(DESCRIPTION);
        subcommands.put("csr", new SigningRequestCommand());
        subcommands.put("cert", new GenerateCertificateCommand());
        subcommands.put("ca", new CertificateAuthorityCommand());
    }


    static final String INTRO_TEXT = "This tool assists you in the generation of X.509 certificates and certificate\n" +
        "signing requests for use with SSL/TLS in the Elastic stack.";

    static final String INSTANCE_EXPLANATION =
        "    * An instance is any piece of the Elastic Stack that requires a SSL certificate.\n" +
            "      Depending on your configuration, Elasticsearch, Logstash, Kibana, and Beats\n" +
            "      may all require a certificate and private key.\n" +
            "    * The minimum required value for each instance is a name. This can simply be the\n" +
            "      hostname, which will be used as the Common Name of the certificate. A full\n" +
            "      distinguished name may also be used.\n" +
            "    * A filename value may be required for each instance. This is necessary when the\n" +
            "      name would result in an invalid file or directory name. The name provided here\n" +
            "      is used as the directory name (within the zip) and the prefix for the key and\n" +
            "      certificate files. The filename is required if you are prompted and the name\n" +
            "      is not displayed in the prompt.\n" +
            "    * IP addresses and DNS names are optional. Multiple values can be specified as a\n" +
            "      comma separated string. If no IP addresses or DNS names are provided, you may\n" +
            "      disable hostname verification in your SSL configuration.";

    static final String CA_EXPLANATION =
        "    * All certificates generated by this tool will be signed by a certificate authority (CA).\n" +
            "    * The tool can automatically generate a new CA for you, or you can provide your own with the\n" +
            "         -ca or -ca-cert command line options.";


    abstract static class CertificateCommand extends EnvironmentAwareCommand {
        // Common option for multiple commands.
        // Not every command uses every option, but where they are common we want to keep them consistent
        final OptionSpec<String> outputPathSpec;
        final OptionSpec<String> outputPasswordSpec;
        final OptionSpec<Integer> keysizeSpec;

        OptionSpec<Void> pemFormatSpec;
        OptionSpec<Integer> daysSpec;

        OptionSpec<String> caPkcs12PathSpec;
        OptionSpec<String> caCertPathSpec;
        OptionSpec<String> caKeyPathSpec;
        OptionSpec<String> caPasswordSpec;
        OptionSpec<String> caDnSpec;
        OptionSpec<Void> keepCaKeySpec;

        OptionSpec<Void> multipleNodesSpec;
        OptionSpec<String> nameSpec;
        OptionSpec<String> dnsNamesSpec;
        OptionSpec<String> ipAddressesSpec;

        OptionSpec<String> inputFileSpec;

        CertificateCommand(String description) {
            super(description);
            outputPathSpec = parser.accepts("out", "path to the output file that should be produced").withRequiredArg();
            outputPasswordSpec = parser.accepts("pass", "password for generated private keys").withOptionalArg();
            keysizeSpec = parser.accepts("keysize", "size in bits of RSA keys").withRequiredArg().ofType(Integer.class);
        }

        final void acceptCertificateGenerationOptions() {
            pemFormatSpec = parser.accepts("pem", "output certificates and keys in PEM format instead of PKCS#12");
            daysSpec = parser.accepts("days", "number of days that the generated certificates are valid")
                .withRequiredArg().ofType(Integer.class);
        }

        final void acceptsCertificateAuthority() {
            caPkcs12PathSpec = parser.accepts("ca", "path to an existing ca key pair (in PKCS#12 format)").withRequiredArg();
            caCertPathSpec = parser.accepts("ca-cert", "path to an existing ca certificate")
                .availableUnless(caPkcs12PathSpec)
                .withRequiredArg();
            caKeyPathSpec = parser.accepts("ca-key", "path to an existing ca private key")
                .availableIf(caCertPathSpec)
                .requiredIf(caCertPathSpec)
                .withRequiredArg();

            keepCaKeySpec = parser.accepts("keep-ca-key", "retain the CA private key for future use")
                .availableUnless(caPkcs12PathSpec)
                .availableUnless(caCertPathSpec);

            caPasswordSpec = parser.accepts("ca-pass", "password for an existing ca private key or the generated ca private key")
                .withOptionalArg();

            acceptsCertificateAuthorityName();
        }

        void acceptsCertificateAuthorityName() {
            OptionSpecBuilder builder = parser.accepts("ca-dn",
                "distinguished name to use for the generated ca. defaults to " + AUTO_GEN_CA_DN);
            if (caPkcs12PathSpec != null) {
                builder = builder.availableUnless(caPkcs12PathSpec);
            }
            if (caCertPathSpec != null) {
                builder = builder.availableUnless(caCertPathSpec);
            }
            caDnSpec = builder.withRequiredArg();
        }

        final void acceptInstanceDetails() {
            multipleNodesSpec = parser.accepts("multiple", "generate files for multiple instances");
            nameSpec = parser.accepts("name", "name of the generated certificate").availableUnless(multipleNodesSpec).withRequiredArg();
            dnsNamesSpec = parser.accepts("dns", "comma separated DNS names").availableUnless(multipleNodesSpec).withRequiredArg();
            ipAddressesSpec = parser.accepts("ip", "comma separated IP addresses").availableUnless(multipleNodesSpec).withRequiredArg();
        }

        final void acceptInputFile() {
            inputFileSpec = parser.accepts("in", "file containing details of the instances in yaml format").withRequiredArg();
        }

        // For testing
        OptionParser getParser() {
            return parser;
        }

        /**
         * Checks for output file in the user specified options or prompts the user for the output file.
         * The resulting path is stored in the {@code config} parameter.
         */
        Path resolveOutputPath(Terminal terminal, OptionSet options, String defaultFilename) throws IOException {
            return resolveOutputPath(terminal, outputPathSpec.value(options), defaultFilename);
        }

        static Path resolveOutputPath(Terminal terminal, String userOption, String defaultFilename) {
            Path file;
            if (userOption != null) {
                file = CertificateTool.resolvePath(userOption);
            } else {
                file = CertificateTool.resolvePath(defaultFilename);
                String input = terminal.readText("Please enter the desired output file [" + file + "]: ");
                if (input.isEmpty() == false) {
                    file = CertificateTool.resolvePath(input);
                }
            }
            return file.toAbsolutePath();
        }

        final int getKeySize(OptionSet options) {
            if (options.has(keysizeSpec)) {
                return keysizeSpec.value(options);
            } else {
                return DEFAULT_KEY_SIZE;
            }
        }

        final int getDays(OptionSet options) {
            if (options.has(daysSpec)) {
                return daysSpec.value(options);
            } else {
                return DEFAULT_DAYS;
            }
        }

        boolean keepCaKey(OptionSet options) {
            return options.has(keepCaKeySpec);
        }

        boolean usePemFormat(OptionSet options) {
            return options.has(pemFormatSpec);
        }

        boolean useOutputPassword(OptionSet options) {
            return options.has(outputPasswordSpec);
        }

        char[] getOutputPassword(OptionSet options) {
            return getChars(outputPasswordSpec.value(options));
        }

        protected Path resolvePath(OptionSet options, OptionSpec<String> spec) {
            final String value = spec.value(options);
            if (Strings.isNullOrEmpty(value)) {
                return null;
            }
            return CertificateTool.resolvePath(value);
        }

        /**
         * Returns the CA certificate and private key that will be used to sign certificates. These may be specified by the user or
         * automatically generated
         *
         * @return CA cert and private key
         */
        CAInfo getCAInfo(Terminal terminal, OptionSet options, Environment env) throws Exception {
            if (options.has(caPkcs12PathSpec)) {
                return loadPkcs12CA(terminal, options, env);
            } else if (options.has(caCertPathSpec)) {
                return loadPemCA(terminal, options, env);
            } else {
                return generateCA(terminal, options);
            }
        }

        private CAInfo loadPkcs12CA(Terminal terminal, OptionSet options, Environment env) throws Exception {
            Path path = resolvePath(options, caPkcs12PathSpec);
            char[] passwordOption = getChars(caPasswordSpec.value(options));

            Map<Certificate, Key> keys = withPassword("CA (" + path + ")", passwordOption,
                terminal, password -> CertParsingUtils.readPkcs12KeyPairs(path, password, a -> password));

            if (keys.size() != 1) {
                throw new IllegalArgumentException("expected a single key in file [" + path.toAbsolutePath() + "] but found [" +
                    keys.size() + "]");
            }
            final Map.Entry<Certificate, Key> pair = keys.entrySet().iterator().next();
            return new CAInfo((X509Certificate) pair.getKey(), (PrivateKey) pair.getValue());
        }

        private CAInfo loadPemCA(Terminal terminal, OptionSet options, Environment env) throws Exception {
            if (options.hasArgument(caKeyPathSpec) == false) {
                throw new UserException(ExitCodes.USAGE, "Option " + caCertPathSpec + " also requires " + caKeyPathSpec);
            }
            Path cert = resolvePath(options, caCertPathSpec);
            Path key = resolvePath(options, caKeyPathSpec);
            String password = caPasswordSpec.value(options);

            final String resolvedCaCertPath = cert.toAbsolutePath().toString();
            Certificate[] certificates = CertParsingUtils.readCertificates(Collections.singletonList(resolvedCaCertPath), env);
            if (certificates.length != 1) {
                throw new IllegalArgumentException("expected a single certificate in file [" + resolvedCaCertPath + "] but found [" +
                    certificates.length + "]");
            }
            X509Certificate caCert = (X509Certificate) certificates[0];
            PrivateKey privateKey = readPrivateKey(key, getChars(password), terminal);
            return new CAInfo(caCert, privateKey);
        }

        CAInfo generateCA(Terminal terminal, OptionSet options) throws Exception {
            String dn = caDnSpec.value(options);
            if (Strings.isNullOrEmpty(dn)) {
                dn = AUTO_GEN_CA_DN;
            }
            X500Principal x500Principal = new X500Principal(dn);
            KeyPair keyPair = CertGenUtils.generateKeyPair(getKeySize(options));
            X509Certificate caCert = CertGenUtils.generateCACertificate(x500Principal, keyPair, getDays(options));

            if (options.hasArgument(caPasswordSpec)) {
                char[] password = getChars(caPasswordSpec.value(options));
                return new CAInfo(caCert, keyPair.getPrivate(), true, password);
            }
            if (options.has(caPasswordSpec)) {
                return withPassword("CA Private key", null, terminal, p -> new CAInfo(caCert, keyPair.getPrivate(), true, p.clone()));
            }
            return new CAInfo(caCert, keyPair.getPrivate(), true, null);
        }

        /**
         * This method handles the collection of information about each instance that is necessary to generate a certificate. The user may
         * be prompted or the information can be gathered from a file
         *
         * @return a {@link Collection} of {@link CertificateInformation} that represents each instance
         */
        Collection<CertificateInformation> getCertificateInformationList(Terminal terminal, OptionSet options)
            throws Exception {
            final Path input = resolvePath(options, inputFileSpec);
            if (input != null) {
                return parseAndValidateFile(terminal, input.toAbsolutePath());
            }
            if (options.has(multipleNodesSpec)) {
                return readMultipleCertificateInformation(terminal);
            } else {
                final Function<String, Stream<? extends String>> splitByComma = v -> Arrays.stream(Strings.splitStringByCommaToArray(v));
                final List<String> dns = dnsNamesSpec.values(options).stream().flatMap(splitByComma).collect(Collectors.toList());
                final List<String> ip = ipAddressesSpec.values(options).stream().flatMap(splitByComma).collect(Collectors.toList());
                final List<String> cn = null;
                final String name = getCertificateName(options);
                final String fileName;
                if (Name.isValidFilename(name)) {
                    fileName = name;
                } else {
                    fileName = requestFileName(terminal, name);
                }
                CertificateInformation information = new CertificateInformation(name, fileName, ip, dns, cn);
                List<String> validationErrors = information.validate();
                if (validationErrors.isEmpty()) {
                    return Collections.singleton(information);
                } else {
                    validationErrors.forEach(terminal::println);
                    return Collections.emptyList();
                }
            }
        }

        protected String getCertificateName(OptionSet options) {
            return options.has(nameSpec) ? nameSpec.value(options) : DEFAULT_CERT_NAME;
        }

        static Collection<CertificateInformation> readMultipleCertificateInformation(Terminal terminal) {
            Map<String, CertificateInformation> map = new HashMap<>();
            boolean done = false;
            while (done == false) {
                String name = terminal.readText("Enter instance name: ");
                if (name.isEmpty() == false) {
                    String filename = requestFileName(terminal, name);
                    String ipAddresses = terminal.readText("Enter IP Addresses for instance (comma-separated if more than one) []: ");
                    String dnsNames = terminal.readText("Enter DNS names for instance (comma-separated if more than one) []: ");
                    List<String> ipList = Arrays.asList(Strings.splitStringByCommaToArray(ipAddresses));
                    List<String> dnsList = Arrays.asList(Strings.splitStringByCommaToArray(dnsNames));
                    List<String> commonNames = null;

                    CertificateInformation information = new CertificateInformation(name, filename, ipList, dnsList, commonNames);
                    List<String> validationErrors = information.validate();
                    if (validationErrors.isEmpty()) {
                        if (map.containsKey(name)) {
                            terminal.println("Overwriting previously defined instance information [" + name + "]");
                        }
                        map.put(name, information);
                    } else {
                        for (String validationError : validationErrors) {
                            terminal.println(validationError);
                        }
                        terminal.println("Skipping entry as invalid values were found");
                    }
                } else {
                    terminal.println("A name must be provided");
                }

                String exit = terminal.readText("Would you like to specify another instance? Press 'y' to continue entering instance " +
                    "information: ");
                if ("y".equals(exit) == false) {
                    done = true;
                }
            }
            return map.values();
        }

        private static String requestFileName(Terminal terminal, String certName) {
            final boolean isNameValidFilename = Name.isValidFilename(certName);
            while (true) {
                String filename = terminal.readText("Enter name for directories and files of " + certName +
                    (isNameValidFilename ? " [" + certName + "]" : "") + ": ");
                if (filename.isEmpty() && isNameValidFilename) {
                    return certName;
                }
                if (Name.isValidFilename(filename)) {
                    return filename;
                } else {
                    terminal.println(Terminal.Verbosity.SILENT, "'" + filename + "' is not a valid filename");
                    continue;
                }
            }
        }

        /**
         * This method handles writing out the certificate authority in PEM format to a zip file.
         *
         * @param outputStream the output stream to write to
         * @param pemWriter    the writer for PEM objects
         * @param info         the certificate authority information
         * @param includeKey   if true, write the CA key in PEM format
         */
        static void writeCAInfo(ZipOutputStream outputStream, JcaPEMWriter pemWriter, CAInfo info, boolean includeKey)
            throws Exception {
            final String caDirName = createCaDirectory(outputStream);
            outputStream.putNextEntry(new ZipEntry(caDirName + "ca.crt"));
            pemWriter.writeObject(info.certAndKey.cert);
            pemWriter.flush();
            outputStream.closeEntry();
            if (includeKey) {
                outputStream.putNextEntry(new ZipEntry(caDirName + "ca.key"));
                if (info.password != null && info.password.length > 0) {
                    try {
                        PEMEncryptor encryptor = getEncrypter(info.password);
                        pemWriter.writeObject(info.certAndKey.key, encryptor);
                    } finally {
                        // we can safely nuke the password chars now
                        Arrays.fill(info.password, (char) 0);
                    }
                } else {
                    pemWriter.writeObject(info.certAndKey.key);
                }
                pemWriter.flush();
                outputStream.closeEntry();
            }
        }

        /**
         * This method handles writing out the certificate authority in PKCS#12 format to a zip file.
         *
         * @param outputStream the output stream to write to
         * @param info         the certificate authority information
         * @param terminal     used to prompt for a password (if not already supplied)
         */
        static void writeCAInfo(ZipOutputStream outputStream, CAInfo info, Terminal terminal) throws Exception {
            final String dirName = createCaDirectory(outputStream);
            final String fileName = dirName + "ca.p12";
            outputStream.putNextEntry(new ZipEntry(fileName));
            withPassword("Generated CA", info.password, terminal, caPassword -> {
                writePkcs12(fileName, outputStream, "ca", info.certAndKey, null, caPassword, null);
                return null;
            });
            outputStream.closeEntry();
        }

        private static String createCaDirectory(ZipOutputStream outputStream) throws IOException {
            final String caDirName = "ca/";
            ZipEntry zipEntry = new ZipEntry(caDirName);
            assert zipEntry.isDirectory();
            outputStream.putNextEntry(zipEntry);
            return caDirName;
        }

        static void writePkcs12(String fileName, OutputStream output, String alias, CertificateAndKey pair, X509Certificate caCert,
                                char[] password, Terminal terminal) throws Exception {
            final KeyStore pkcs12 = KeyStore.getInstance("PKCS12");
            pkcs12.load(null);
            withPassword(fileName, password, terminal, p12Password -> {
                if (isAscii(p12Password)) {
                    pkcs12.setKeyEntry(alias, pair.key, p12Password, new Certificate[]{pair.cert});
                    if (caCert != null) {
                        pkcs12.setCertificateEntry("ca", caCert);
                    }
                    pkcs12.store(output, p12Password);
                    return null;
                } else {
                    throw new UserException(ExitCodes.CONFIG, "PKCS#12 passwords must be plain ASCII");
                }
            });
        }
    }

    static class SigningRequestCommand extends CertificateCommand {

        SigningRequestCommand() {
            super("generate certificate signing requests");
            acceptInstanceDetails();
            acceptInputFile();
        }

        @Override
        protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
            terminal.println(INTRO_TEXT);
            terminal.println("");
            terminal.println("The 'csr' mode generates certificate signing requests that can be sent to");
            terminal.println("a trusted certificate authority");
            terminal.println("    * By default, this generates a single CSR for a single instance.");
            terminal.println("    * You can use the '-multiple' option to generate CSRs for multiple");
            terminal.println("       instances, each with their own private key.");
            terminal.println("    * The '-in' option allows for the CSR generation to be automated");
            terminal.println("       by describing the details of each instance in a YAML file");
            terminal.println("");
            terminal.println(INSTANCE_EXPLANATION);
            terminal.println("");
            terminal.println("The 'csr' mode produces a single zip file which contains the certificate");
            terminal.println("signing requests and private keys for each instance.");
            terminal.println("    * Each certificate signing request is provided as a standard PEM encoding of a PKCS#10 CSR.");
            terminal.println("    * Each key is provided as a PEM encoding of an RSA private key");
            terminal.println("");

            final Path output = resolveOutputPath(terminal, options, DEFAULT_CSR_ZIP);
            final int keySize = getKeySize(options);
            Collection<CertificateInformation> certificateInformations = getCertificateInformationList(terminal, options);
            generateAndWriteCsrs(output, keySize, certificateInformations);

            terminal.println("");
            terminal.println("Certificate signing requests have been written to " + output);
            terminal.println("");
            terminal.println("This file should be properly secured as it contains the private keys for all");
            terminal.println("instances.");
            terminal.println("");
            terminal.println("After unzipping the file, there will be a directory for each instance containing");
            terminal.println("the certificate signing request and the private key. Provide the certificate");
            terminal.println("signing requests to your certificate authority. Once you have received the");
            terminal.println("signed certificate, copy the signed certificate, key, and CA certificate to the");
            terminal.println("configuration directory of the Elastic product that they will be used for and");
            terminal.println("follow the SSL configuration instructions in the product guide.");
        }

        /**
         * Generates certificate signing requests and writes them out to the specified file in zip format
         *
         * @param certInfo the details to use in the certificate signing requests
         */
        void generateAndWriteCsrs(Path output, int keySize, Collection<CertificateInformation> certInfo) throws Exception {
            fullyWriteZipFile(output, (outputStream, pemWriter) -> {
                for (CertificateInformation certificateInformation : certInfo) {
                    KeyPair keyPair = CertGenUtils.generateKeyPair(keySize);
                    GeneralNames sanList = getSubjectAlternativeNamesValue(certificateInformation.ipAddresses,
                        certificateInformation.dnsNames, certificateInformation.commonNames);
                    PKCS10CertificationRequest csr = CertGenUtils.generateCSR(keyPair, certificateInformation.name.x500Principal, sanList);

                    final String dirName = certificateInformation.name.filename + "/";
                    ZipEntry zipEntry = new ZipEntry(dirName);
                    assert zipEntry.isDirectory();
                    outputStream.putNextEntry(zipEntry);

                    // write csr
                    outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".csr"));
                    pemWriter.writeObject(csr);
                    pemWriter.flush();
                    outputStream.closeEntry();

                    // write private key
                    outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".key"));
                    pemWriter.writeObject(keyPair.getPrivate());
                    pemWriter.flush();
                    outputStream.closeEntry();
                }
            });
        }
    }

    static class GenerateCertificateCommand extends CertificateCommand {

        GenerateCertificateCommand() {
            super("generate X.509 certificates and keys");
            acceptCertificateGenerationOptions();
            acceptInstanceDetails();
            acceptsCertificateAuthority();
            acceptInputFile();
        }

        @Override
        protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
            terminal.println(INTRO_TEXT);
            terminal.println("");
            terminal.println("The 'cert' mode generates X.509 certificate and private keys.");
            terminal.println("    * By default, this generates a single certificate and key for use");
            terminal.println("       on a single instance.");
            terminal.println("    * The '-multiple' option will prompt you to enter details for multiple");
            terminal.println("       instances and will generate a certificate and key for each one");
            terminal.println("    * The '-in' option allows for the certificate generation to be automated by describing");
            terminal.println("       the details of each instance in a YAML file");
            terminal.println("");
            terminal.println(INSTANCE_EXPLANATION);
            terminal.println("");
            terminal.println(CA_EXPLANATION);
            terminal.println("");
            terminal.println("By default the 'cert' mode produces a single PKCS#12 output file which holds:");
            terminal.println("    * The instance certificate");
            terminal.println("    * The private key for the instance certificate");
            terminal.println("    * The CA certificate");
            terminal.println("");
            terminal.println("If you specify any of the following options:");
            terminal.println("    * -pem (PEM formatted output)");
            terminal.println("    * -keep-ca-key (retain generated CA key)");
            terminal.println("    * -multiple (generate multiple certificates)");
            terminal.println("    * -in (generate certificates from an input file)");
            terminal.println("then the output will be be a zip file containing individual certificate/key files");
            terminal.println("");

            CAInfo caInfo = getCAInfo(terminal, options, env);
            Collection<CertificateInformation> certInfo = getCertificateInformationList(terminal, options);
            final boolean keepCaKey = keepCaKey(options);
            final boolean usePemFormat = usePemFormat(options);
            final boolean writeZipFile = options.has(multipleNodesSpec) || options.has(inputFileSpec) || keepCaKey || usePemFormat;

            final String outputName;
            if (writeZipFile) {
                outputName = DEFAULT_CERT_ZIP;
            } else if (options.has(nameSpec)) {
                outputName = nameSpec.value(options) + ".p12";
            } else {
                outputName = "elastic-certificates.p12";
            }
            final Path output = resolveOutputPath(terminal, options, outputName);

            generateAndWriteSignedCertificates(output, writeZipFile, options, certInfo, caInfo, terminal);

            terminal.println("");
            terminal.println("Certificates written to " + output);
            terminal.println("");
            if (certInfo.size() > 1) {
                terminal.println(Terminal.Verbosity.NORMAL, "This file should be properly secured as it contains the private keys for ");
                terminal.print(Terminal.Verbosity.NORMAL, "all instances");
            } else {
                terminal.println(Terminal.Verbosity.NORMAL, "This file should be properly secured as it contains the private key for ");
                terminal.print(Terminal.Verbosity.NORMAL, "your instance");
            }
            if (caInfo.generated && keepCaKey) {
                terminal.println(Terminal.Verbosity.NORMAL, " and for the certificate authority.");
            } else {
                terminal.println(Terminal.Verbosity.NORMAL, ".");
            }
            terminal.println("");
            final String filesDescription;
            if (writeZipFile) {
                terminal.println("After unzipping the file, there will be a directory for each instance.");
                if (usePemFormat) {
                    terminal.println("Each instance has a certificate and private key.");
                    filesDescription = "the certificate, key, and CA certificate";
                } else {
                    terminal.println("Each instance has a single PKCS#12 (.p12) file containing the instance");
                    terminal.println("certificate, instance private key and the CA certificate");
                    filesDescription = "this '.p12' file";
                }
            } else {
                terminal.println("This file is a self contained file and can be copied and used 'as is'");
                filesDescription = "this '.p12' file";
            }
            terminal.println("For each Elastic product that you wish to configure, you should copy");
            terminal.println(filesDescription + " to the relevant configuration directory");
            terminal.println("and then follow the SSL configuration instructions in the product guide.");
            terminal.println("");
            if (usePemFormat || caInfo.generated == false) {
                terminal.println("For client applications, you may only need to copy the CA certificate and");
                terminal.println("configure the client to trust this certificate.");
            }
        }

        /**
         * Generates signed certificates in either PKCS#12 format or PEM format, wrapped in a zip file if necessary.
         *
         * @param output       the output file (either zip, or PKCS#12)
         * @param writeZipFile if true, output a zip file, otherwise output a single PKCS#12 file
         * @param options      the current command line options
         * @param certs        the certificates to write to the file
         * @param caInfo       the CA information to sign the certificates with
         * @param terminal     the terminal to use if prompting for passwords
         */
        void generateAndWriteSignedCertificates(Path output, boolean writeZipFile, OptionSet options,
                                                Collection<CertificateInformation> certs, CAInfo caInfo, Terminal terminal)
            throws Exception {

            checkDirectory(output, terminal);

            final int keySize = getKeySize(options);
            final int days = getDays(options);
            final char[] outputPassword = super.getOutputPassword(options);
            if (writeZipFile) {
                final boolean usePem = usePemFormat(options);
                final boolean usePassword = super.useOutputPassword(options);
                fullyWriteZipFile(output, (outputStream, pemWriter) -> {
                    // write out the CA info first if it was generated
                    if (caInfo.generated) {
                        final boolean writeCAKey = keepCaKey(options);
                        if (usePem) {
                            writeCAInfo(outputStream, pemWriter, caInfo, writeCAKey);
                        } else if (writeCAKey) {
                            writeCAInfo(outputStream, caInfo, terminal);
                        }
                    }

                    for (CertificateInformation certificateInformation : certs) {
                        CertificateAndKey pair = generateCertificateAndKey(certificateInformation, caInfo, keySize, days);

                        final String dirName = certificateInformation.name.filename + "/";
                        ZipEntry zipEntry = new ZipEntry(dirName);
                        assert zipEntry.isDirectory();
                        outputStream.putNextEntry(zipEntry);

                        final String entryBase = dirName + certificateInformation.name.filename;

                        if (usePem) {
                            // write cert
                            outputStream.putNextEntry(new ZipEntry(entryBase + ".crt"));
                            pemWriter.writeObject(pair.cert);
                            pemWriter.flush();
                            outputStream.closeEntry();

                            // write private key
                            final String keyFileName = entryBase + ".key";
                            outputStream.putNextEntry(new ZipEntry(keyFileName));
                            if (usePassword) {
                                withPassword(keyFileName, outputPassword, terminal, password -> {
                                    pemWriter.writeObject(pair.key, getEncrypter(password));
                                    return null;
                                });
                            } else {
                                pemWriter.writeObject(pair.key);
                            }
                            pemWriter.flush();
                            outputStream.closeEntry();
                        } else {
                            final String fileName = entryBase + ".p12";
                            outputStream.putNextEntry(new ZipEntry(fileName));
                            writePkcs12(fileName, outputStream, certificateInformation.name.originalName, pair, caInfo.certAndKey.cert,
                                outputPassword, terminal);
                            outputStream.closeEntry();
                        }
                    }
                });
            } else {
                assert certs.size() == 1;
                CertificateInformation certificateInformation = certs.iterator().next();
                CertificateAndKey pair = generateCertificateAndKey(certificateInformation, caInfo, keySize, days);
                fullyWriteFile(output, stream -> writePkcs12(output.getFileName().toString(), stream,
                    certificateInformation.name.originalName, pair, caInfo.certAndKey.cert, outputPassword, terminal));
            }
        }

        private CertificateAndKey generateCertificateAndKey(CertificateInformation certificateInformation, CAInfo caInfo,
                                                            int keySize, int days) throws Exception {
            KeyPair keyPair = CertGenUtils.generateKeyPair(keySize);
            Certificate certificate = CertGenUtils.generateSignedCertificate(certificateInformation.name.x500Principal,
                getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames,
                    certificateInformation.commonNames),
                keyPair, caInfo.certAndKey.cert, caInfo.certAndKey.key, days);
            return new CertificateAndKey((X509Certificate) certificate, keyPair.getPrivate());
        }

    }

    static class CertificateAuthorityCommand extends CertificateCommand {

        CertificateAuthorityCommand() {
            super("generate a new local certificate authority");
            acceptCertificateGenerationOptions();
            acceptsCertificateAuthorityName();
            super.caPasswordSpec = super.outputPasswordSpec;
        }

        @Override
        protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
            terminal.println(INTRO_TEXT);
            terminal.println("");
            terminal.println("The 'ca' mode generates a new 'certificate authority'");
            terminal.println("This will create a new X.509 certificate and private key that can be used");
            terminal.println("to sign certificate when running in 'cert' mode.");
            terminal.println("");
            terminal.println("Use the 'ca-dn' option if you wish to configure the 'distinguished name'");
            terminal.println("of the certificate authority");
            terminal.println("");
            terminal.println("By default the 'ca' mode produces a single PKCS#12 output file which holds:");
            terminal.println("    * The CA certificate");
            terminal.println("    * The CA's private key");
            terminal.println("");
            terminal.println("If you elect to generate PEM format certificates (the -pem option), then the output will");
            terminal.println("be a zip file containing individual files for the CA certificate and private key");
            terminal.println("");

            CAInfo caInfo = generateCA(terminal, options);
            final boolean writeZipFile = usePemFormat(options);
            final Path output = resolveOutputPath(terminal, options, writeZipFile ? DEFAULT_CA_ZIP : DEFAULT_CA_P12);
            writeCertificateAuthority(output, caInfo, writeZipFile, terminal);
        }

        private void writeCertificateAuthority(Path output, CAInfo caInfo, boolean writePemZip, Terminal terminal) throws Exception {
            checkDirectory(output, terminal);
            if (writePemZip) {
                fullyWriteZipFile(output, (outputStream, pemWriter) -> writeCAInfo(outputStream, pemWriter, caInfo, true));
            } else {
                final String fileName = output.getFileName().toString();
                fullyWriteFile(output, outputStream ->
                    writePkcs12(fileName, outputStream, "ca", caInfo.certAndKey, null, caInfo.password, terminal));
            }
        }
    }

    @SuppressForbidden(reason = "resolve paths against CWD for a CLI tool")
    static Path resolvePath(String pathStr) {
        return PathUtils.get(pathStr).normalize();
    }

    static Collection<CertificateInformation> parseAndValidateFile(Terminal terminal, Path file) throws Exception {
        final Collection<CertificateInformation> config = parseFile(file);
        boolean hasError = false;
        for (CertificateInformation certInfo : config) {
            final List<String> errors = certInfo.validate();
            if (errors.size() > 0) {
                hasError = true;
                terminal.println(Verbosity.SILENT, "Configuration for instance " + certInfo.name.originalName + " has invalid details");
                for (String message : errors) {
                    terminal.println(Verbosity.SILENT, " * " + message);
                }
                terminal.println("");
            }
        }
        if (hasError) {
            throw new UserException(ExitCodes.CONFIG, "File " + file + " contains invalid configuration details (see messages above)");
        }
        return config;
    }

    /**
     * Parses the input file to retrieve the certificate information
     *
     * @param file the file to parse
     * @return a collection of certificate information
     */
    static Collection<CertificateInformation> parseFile(Path file) throws Exception {
        try (Reader reader = Files.newBufferedReader(file)) {
            // EMPTY is safe here because we never use namedObject
            XContentParser xContentParser = XContentType.YAML.xContent()
                .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, reader);
            return CertificateToolParser.PARSER.parse(xContentParser, new ArrayList<>(), null);
        }
    }

    private static PEMEncryptor getEncrypter(char[] password) {
        return new JcePEMEncryptorBuilder("DES-EDE3-CBC").setProvider(BC_PROV).build(password);
    }

    private static <T, E extends Exception> T withPassword(String description, char[] password, Terminal terminal,
                                                           CheckedFunction<char[], T, E> body) throws E {
        if (password == null) {
            char[] promptedValue = terminal.readSecret("Enter password for " + description + " : ");
            try {
                return body.apply(promptedValue);
            } finally {
                Arrays.fill(promptedValue, (char) 0);
            }
        } else {
            return body.apply(password);
        }
    }

    /**
     * This method handles the deletion of a file in the case of a partial write
     *
     * @param file   the file that is being written to
     * @param writer writes the contents of the file
     */
    private static void fullyWriteZipFile(Path file, Writer writer) throws Exception {
        fullyWriteFile(file, outputStream -> {
            try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8);
                 JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(zipOutputStream, StandardCharsets.UTF_8))) {
                writer.write(zipOutputStream, pemWriter);
            }
        });
    }

    /**
     * Checks whether the parent directories of {@code path} exist, and offers to create them if needed.
     */
    private static void checkDirectory(Path path, Terminal terminal) throws UserException {
        final Path parent = path.getParent();
        if (Files.isDirectory(parent)) {
            return;
        }
        if (Files.exists(parent)) {
            terminal.println(Terminal.Verbosity.SILENT, "Path " + parent + " exists, but is not a directory. Cannot write to " + path);
            throw new UserException(ExitCodes.CANT_CREATE, "Cannot write to " + path);
        }
        if (terminal.promptYesNo("Directory " + parent + " does not exist. Do you want to create it?", true)) {
            try {
                Files.createDirectories(parent);
            } catch (IOException e) {
                throw new UserException(ExitCodes.CANT_CREATE, "Cannot create directory " + parent, e);
            }
        } else {
            throw new UserException(ExitCodes.CANT_CREATE, "Directory " + parent + " does not exist");
        }

    }

    /**
     * This method handles the deletion of a file in the case of a partial write
     *
     * @param file   the file that is being written to
     * @param writer writes the contents of the file
     */
    private static void fullyWriteFile(Path file, CheckedConsumer<OutputStream, Exception> writer) throws Exception {
        assert file != null;
        assert writer != null;

        boolean success = false;
        if (Files.exists(file)) {
            throw new UserException(ExitCodes.IO_ERROR, "Output file '" + file + "' already exists");
        }
        try (OutputStream outputStream = Files.newOutputStream(file, StandardOpenOption.CREATE_NEW)) {
            writer.accept(outputStream);

            // set permissions to 600
            PosixFileAttributeView view = Files.getFileAttributeView(file, PosixFileAttributeView.class);
            if (view != null) {
                view.setPermissions(Sets.newHashSet(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
            }

            success = true;
        } finally {
            if (success == false) {
                Files.deleteIfExists(file);
            }
        }
    }

    /**
     * Helper method to read a private key and support prompting of user for a key. To avoid passwords being placed as an argument we
     * can prompt the user for their password if we encounter an encrypted key.
     *
     * @param path     the path to the private key
     * @param password the password provided by the user or {@code null}
     * @param terminal the terminal to use for user interaction
     * @return the {@link PrivateKey} that was read from the file
     */
    private static PrivateKey readPrivateKey(Path path, char[] password, Terminal terminal)
        throws Exception {
        AtomicReference<char[]> passwordReference = new AtomicReference<>(password);
        try {
            return PemUtils.readPrivateKey(path, () -> {
                if (password != null) {
                    return password;
                }
                char[] promptedValue = terminal.readSecret("Enter password for CA private key (" + path.getFileName() + ") : ");
                passwordReference.set(promptedValue);
                return promptedValue;
            });
        } finally {
            if (passwordReference.get() != null) {
                Arrays.fill(passwordReference.get(), (char) 0);
            }
        }
    }

    private static GeneralNames getSubjectAlternativeNamesValue(List<String> ipAddresses, List<String> dnsNames, List<String> commonNames) {
        Set<GeneralName> generalNameList = new HashSet<>();
        for (String ip : ipAddresses) {
            generalNameList.add(new GeneralName(GeneralName.iPAddress, ip));
        }

        for (String dns : dnsNames) {
            generalNameList.add(new GeneralName(GeneralName.dNSName, dns));
        }

        for (String cn : commonNames) {
            generalNameList.add(CertGenUtils.createCommonName(cn));
        }

        if (generalNameList.isEmpty()) {
            return null;
        }
        return new GeneralNames(generalNameList.toArray(new GeneralName[0]));
    }

    private static boolean isAscii(char[] str) {
        return ASCII_ENCODER.canEncode(CharBuffer.wrap(str));
    }

    private static char[] getChars(String password) {
        return password == null ? null : password.toCharArray();
    }


    static class CertificateInformation {
        final Name name;
        final List<String> ipAddresses;
        final List<String> dnsNames;
        final List<String> commonNames;

        CertificateInformation(String name, String filename, List<String> ipAddresses, List<String> dnsNames, List<String> commonNames) {
            this.name = Name.fromUserProvidedName(name, filename);
            this.ipAddresses = ipAddresses == null ? Collections.emptyList() : ipAddresses;
            this.dnsNames = dnsNames == null ? Collections.emptyList() : dnsNames;
            this.commonNames = commonNames == null ? Collections.emptyList() : commonNames;
        }

        List<String> validate() {
            List<String> errors = new ArrayList<>();
            if (name.error != null) {
                errors.add(name.error);
            }
            for (String ip : ipAddresses) {
                if (InetAddresses.isInetAddress(ip) == false) {
                    errors.add("[" + ip + "] is not a valid IP address");
                }
            }
            for (String dnsName : dnsNames) {
                if (DERIA5String.isIA5String(dnsName) == false) {
                    errors.add("[" + dnsName + "] is not a valid DNS name");
                }
            }
            return errors;
        }
    }

    static class Name {

        final String originalName;
        final X500Principal x500Principal;
        final String filename;
        final String error;

        private Name(String name, X500Principal x500Principal, String filename, String error) {
            this.originalName = name;
            this.x500Principal = x500Principal;
            this.filename = filename;
            this.error = error;
        }

        static Name fromUserProvidedName(String name, String filename) {
            if ("ca".equals(name)) {
                return new Name(name, null, null, "[ca] may not be used as an instance name");
            }
            if (name == null) {
                return new Name("", null, null, "instance name may not be null");
            }

            final X500Principal principal;
            try {
                if (name.contains("=")) {
                    principal = new X500Principal(name);
                } else {
                    principal = new X500Principal("CN=" + name);
                }
            } catch (IllegalArgumentException e) {
                String error = "[" + name + "] could not be converted to a valid DN\n" + e.getMessage() + "\n"
                    + ExceptionsHelper.stackTrace(e);
                return new Name(name, null, null, error);
            }

            boolean validFilename = isValidFilename(filename);
            if (validFilename == false) {
                return new Name(name, principal, null, "[" + filename + "] is not a valid filename");
            }
            return new Name(name, principal, resolvePath(filename).toString(), null);
        }

        static boolean isValidFilename(String name) {
            return ALLOWED_FILENAME_CHAR_PATTERN.matcher(name).matches()
                && ALLOWED_FILENAME_CHAR_PATTERN.matcher(resolvePath(name).toString()).matches()
                && name.startsWith(".") == false;
        }

        @Override
        public String toString() {
            return getClass().getSimpleName()
                + "{original=[" + originalName + "] principal=[" + x500Principal
                + "] file=[" + filename + "] err=[" + error + "]}";
        }
    }

    static class CertificateAndKey {
        final X509Certificate cert;
        final PrivateKey key;

        CertificateAndKey(X509Certificate cert, PrivateKey key) {
            this.cert = cert;
            this.key = key;
        }
    }

    static class CAInfo {
        final CertificateAndKey certAndKey;
        final boolean generated;
        final char[] password;

        CAInfo(X509Certificate caCert, PrivateKey privateKey) {
            this(caCert, privateKey, false, null);
        }

        CAInfo(X509Certificate caCert, PrivateKey privateKey, boolean generated, char[] password) {
            this.certAndKey = new CertificateAndKey(caCert, privateKey);
            this.generated = generated;
            this.password = password;
        }
    }

    private interface Writer {
        void write(ZipOutputStream zipOutputStream, JcaPEMWriter pemWriter) throws Exception;
    }

}
